import ts from 'typescript';
import chalk from 'chalk';
import {readFileSync} from 'fs';
import {join} from 'path';
import {sync as glob} from 'glob';

// This script aims to detect if the unique IDs of two components may collide at runtime
// in order to avoid issues like https://github.com/angular/components/issues/27163.

const errors: string[] = [];
const seenMetadata = new Map<string, ts.ClassDeclaration>();
const fileToCheck = join(__dirname, '../src/**/!(*.spec).ts');
const ignoredPatterns = [
  '**/components-examples/**',
  '**/dev-app/**',
  '**/e2e-app/**',
  '**/universal-app/**',
];

// Use glob + createSourceFile since we don't need type information
// and this generally faster than creating a program.
glob(fileToCheck, {absolute: true, ignore: ignoredPatterns})
  .map(path => ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true))
  .forEach(sourceFile => {
    sourceFile.statements.forEach(statement => {
      if (ts.isClassDeclaration(statement)) {
        validateClass(statement);
      }
    });
  });

if (errors.length) {
  console.error(chalk.red('Detected identical metadata between following components:'));
  errors.forEach(err => console.error(chalk.red(err)));
  console.error(
    chalk.red(
      `\nThe metadata of one of each of these components should be ` +
        `changed to be slightly different in order to avoid conflicts at runtime.\n`,
    ),
  );

  process.exit(1);
}

/** Validates if a class will conflict with any of the classes that have been checked so far. */
function validateClass(node: ts.ClassDeclaration): void {
  const metadata = getComponentMetadata(node);

  if (metadata) {
    // Create an ID for the component based on its metadata. This is based on what the framework
    // does at runtime at https://github.com/angular/angular/blob/main/packages/core/src/render3/definition.ts#L679.
    // Note that the behavior isn't exactly the same, because the framework uses some fields that
    // are generated by the compiler based on the component's template.
    const key = [
      serializeField('selector', metadata),
      serializeField('host', metadata),
      serializeField('encapsulation', metadata),
      serializeField('standalone', metadata),
      serializeField('signals', metadata),
      serializeField('exportAs', metadata),
      serializeBindings(node, metadata, 'inputs', 'Input'),
      serializeBindings(node, metadata, 'outputs', 'Output'),
    ].join('|');

    if (seenMetadata.has(key)) {
      errors.push(`- ${node.name?.text} and ${seenMetadata.get(key)!.name?.text}`);
    } else {
      seenMetadata.set(key, node);
    }
  }
}

/** Serializes a field of an object literal node to a string. */
function serializeField(name: string, metadata: ts.ObjectLiteralExpression): string {
  const prop = findPropAssignment(name, metadata);
  return prop ? serializeValue(prop.initializer).trim() : '<none>';
}

/** Extracts the input/output bindings of a component and serializes them into a string. */
function serializeBindings(
  node: ts.ClassDeclaration,
  metadata: ts.ObjectLiteralExpression,
  metaName: string,
  decoratorName: string,
): string {
  const bindings: Record<string, string> = {};
  const metaProp = findPropAssignment(metaName, metadata);

  if (metaProp && ts.isArrayLiteralExpression(metaProp.initializer)) {
    metaProp.initializer.elements.forEach(el => {
      if (ts.isStringLiteralLike(el)) {
        const [name, alias] = el.text.split(':').map(p => p.trim());
        bindings[alias || name] = name;
      } else if (ts.isObjectLiteralExpression(el)) {
        const name = findPropAssignment('name', el);
        const alias = findPropAssignment('alias', el);

        if (name && ts.isStringLiteralLike(name.initializer)) {
          const publicName =
            alias && ts.isStringLiteralLike(alias.initializer)
              ? alias.initializer.text
              : name.initializer.text;
          bindings[publicName] = name.initializer.text;
        }
      }
    });
  }

  node.members.forEach(member => {
    if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) {
      return;
    }

    const decorator = findDecorator(decoratorName, member);

    if (decorator) {
      const publicName =
        decorator.expression.arguments.length > 0 &&
        ts.isStringLiteralLike(decorator.expression.arguments[0])
          ? decorator.expression.arguments[0].text
          : member.name.text;
      bindings[publicName] = member.name.text;
    }
  });

  return JSON.stringify(bindings);
}

/** Serializes a single value to a string. */
function serializeValue(node: ts.Node): string {
  if (ts.isStringLiteralLike(node) || ts.isIdentifier(node)) {
    return node.text;
  }

  if (ts.isArrayLiteralExpression(node)) {
    return JSON.stringify(node.elements.map(serializeValue));
  }

  if (ts.isObjectLiteralExpression(node)) {
    const serialized = node.properties
      .slice()
      // Sort the fields since JS engines preserve the order properties in object literals.
      .sort((a, b) => (a.name?.getText() || '').localeCompare(b.name?.getText() || ''))
      .reduce((accumulator, prop) => {
        if (ts.isPropertyAssignment(prop)) {
          accumulator[prop.name.getText()] = serializeValue(prop.initializer);
        }

        return accumulator;
      }, {} as Record<string, string>);

    return JSON.stringify(serialized);
  }

  return node.getText();
}

/** Gets the object literal containing the Angular component metadata of a class. */
function getComponentMetadata(node: ts.ClassDeclaration): ts.ObjectLiteralExpression | null {
  const decorator = findDecorator('Component', node);

  if (!decorator) {
    return null;
  }

  if (
    decorator.expression.arguments.length === 0 ||
    !ts.isObjectLiteralExpression(decorator.expression.arguments[0])
  ) {
    throw new Error(
      `Cannot analyze class ${node.name?.text || 'Anonymous'} in ${node.getSourceFile().fileName}.`,
    );
  }

  return decorator.expression.arguments[0];
}

/** Finds a decorator with a specific name on a node. */
function findDecorator(name: string, node: ts.HasDecorators) {
  return ts
    .getDecorators(node)
    ?.find(
      current =>
        ts.isCallExpression(current.expression) &&
        ts.isIdentifier(current.expression.expression) &&
        current.expression.expression.text === name,
    ) as (ts.Decorator & {expression: ts.CallExpression}) | undefined;
}

/** Finds a specific property of an object literal node. */
function findPropAssignment(
  name: string,
  literal: ts.ObjectLiteralExpression,
): ts.PropertyAssignment | undefined {
  return literal.properties.find(
    current =>
      ts.isPropertyAssignment(current) &&
      ts.isIdentifier(current.name) &&
      current.name.text === name,
  ) as ts.PropertyAssignment | undefined;
}
